// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.notifications;

import android.app.Notification;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.net.Uri;
import android.os.Bundle;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.text.style.StyleSpan;
import android.util.Log;

import org.chromium.base.CommandLine;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.annotations.CalledByNative;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeSwitches;
import org.chromium.chrome.browser.UrlUtilities;
import org.chromium.chrome.browser.preferences.Preferences;
import org.chromium.chrome.browser.preferences.PreferencesLauncher;
import org.chromium.chrome.browser.preferences.website.SingleCategoryPreferences;
import org.chromium.chrome.browser.preferences.website.SingleWebsitePreferences;
import org.chromium.chrome.browser.preferences.website.SiteSettingsCategory;
import org.chromium.chrome.browser.widget.RoundedIconGenerator;

import java.net.URI;
import java.net.URISyntaxException;

import javax.annotation.Nullable;

/**
 * Provides the ability for the NotificationUIManagerAndroid to talk to the Android platform
 * notification manager.
 *
 * This class should only be used on the UI thread.
 */
public class NotificationUIManager {
    private static final String TAG = NotificationUIManager.class.getSimpleName();

    // We always use the same integer id when showing and closing notifications. The notification
    // tag is always set, which is a safe and sufficient way of identifying a notification, so the
    // integer id is not needed anymore except it must not vary in an uncontrolled way.
    @VisibleForTesting static final int PLATFORM_ID = -1;

    // Prefix for platform tags generated by this class. This allows us to verify when reading a tag
    // that it was set by us.
    private static final String PLATFORM_TAG_PREFIX = NotificationUIManager.class.getSimpleName();

    private static final int NOTIFICATION_ICON_BG_COLOR = Color.rgb(150, 150, 150);
    private static final int NOTIFICATION_TEXT_SIZE_DP = 28;

    // We always use the same request code for pending intents. We use other ways to force
    // uniqueness of pending intents when necessary.
    private static final int PENDING_INTENT_REQUEST_CODE = 0;

    private static NotificationUIManager sInstance;
    private static NotificationManagerProxy sNotificationManagerOverride;

    private final long mNativeNotificationManager;

    private final Context mAppContext;
    private final NotificationManagerProxy mNotificationManager;

    @VisibleForTesting public RoundedIconGenerator mIconGenerator;
    private final int mLargeIconWidthPx;
    private final int mLargeIconHeightPx;
    private final float mDensity;

    private long mLastNotificationClickMs = 0L;

    /**
     * Creates a new instance of the NotificationUIManager.
     *
     * @param nativeNotificationManager Instance of the NotificationUIManagerAndroid class.
     * @param context Application context for this instance of Chrome.
     */
    @CalledByNative
    private static NotificationUIManager create(long nativeNotificationManager, Context context) {
        if (sInstance != null) {
            throw new IllegalStateException("There must only be a single NotificationUIManager.");
        }

        sInstance = new NotificationUIManager(nativeNotificationManager, context);
        return sInstance;
    }

    /**
     * Returns the current instance of the NotificationUIManager.
     *
     * @return The instance of the NotificationUIManager, if any.
     */
    @Nullable
    @VisibleForTesting
    static NotificationUIManager getInstanceForTests() {
        return sInstance;
    }

    /**
     * Overrides the notification manager which is to be used for displaying Notifications on the
     * Android framework. Should only be used for testing. Tests are expected to clean up after
     * themselves by setting this to NULL again.
     *
     * @param proxy The notification manager instance to use instead of the system's.
     */
    @VisibleForTesting
    public static void overrideNotificationManagerForTesting(
            NotificationManagerProxy notificationManager) {
        sNotificationManagerOverride = notificationManager;
    }

    private NotificationUIManager(long nativeNotificationManager, Context context) {
        mNativeNotificationManager = nativeNotificationManager;
        mAppContext = context.getApplicationContext();

        if (sNotificationManagerOverride != null) {
            mNotificationManager = sNotificationManagerOverride;
        } else {
            mNotificationManager = new NotificationManagerProxyImpl(
                    (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE));
        }

        Resources resources = mAppContext.getResources();

        mLargeIconWidthPx =
                resources.getDimensionPixelSize(android.R.dimen.notification_large_icon_width);
        mLargeIconHeightPx =
                resources.getDimensionPixelSize(android.R.dimen.notification_large_icon_height);
        mDensity = resources.getDisplayMetrics().density;
    }

    /**
     * Marks the current instance as being freed, allowing for a new NotificationUIManager
     * object to be initialized.
     */
    @CalledByNative
    private void destroy() {
        assert sInstance == this;
        sInstance = null;
    }

    /**
     * Invoked by the NotificationService when a Notification intent has been received. There may
     * not be an active instance of the NotificationUIManager at this time, so inform the native
     * side through a static method, initializing the manager if needed.
     *
     * @param intent The intent as received by the Notification service.
     * @return Whether the event could be handled by the native Notification manager.
     */
    public static boolean dispatchNotificationEvent(Intent intent) {
        if (sInstance == null) {
            nativeInitializeNotificationUIManager();
            if (sInstance == null) {
                Log.e(TAG, "Unable to initialize the native NotificationUIManager.");
                return false;
            }
        }

        long persistentNotificationId =
                intent.getLongExtra(NotificationConstants.EXTRA_PERSISTENT_NOTIFICATION_ID, -1);
        String origin = intent.getStringExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_ORIGIN);
        String tag = intent.getStringExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_TAG);

        Log.i(TAG, "Dispatching notification event to native: " + persistentNotificationId);

        if (NotificationConstants.ACTION_CLICK_NOTIFICATION.equals(intent.getAction())) {
            int actionIndex = intent.getIntExtra(
                    NotificationConstants.EXTRA_NOTIFICATION_INFO_ACTION_INDEX, -1);
            return sInstance.onNotificationClicked(persistentNotificationId, origin, tag,
                                                   actionIndex);
        } else if (NotificationConstants.ACTION_CLOSE_NOTIFICATION.equals(intent.getAction())) {
            // Notification deleteIntent is executed only "when the notification is explicitly
            // dismissed by the user, either with the 'Clear All' button or by swiping it away
            // individually" (though a third-party NotificationListenerService may also trigger it).
            return sInstance.onNotificationClosed(persistentNotificationId, origin, tag,
                                                  true /* byUser */);
        }

        Log.e(TAG, "Unrecognized Notification action: " + intent.getAction());
        return false;
    }

    /**
     * Launches the notifications preferences screen. If the received intent indicates it came
     * from the gear button on a flipped notification, this launches the site specific preferences
     * screen.
     *
     * @param context The context that received the intent.
     * @param incomingIntent The received intent.
     */
    public static void launchNotificationPreferences(Context context, Intent incomingIntent) {
        // Use the application context because it lives longer. When using he given context, it
        // may be stopped before the preferences intent is handled.
        Context applicationContext = context.getApplicationContext();

        // If we can read an origin from the intent, use it to open the settings screen for that
        // origin.
        String origin = getOriginFromTag(
                incomingIntent.getStringExtra(NotificationConstants.EXTRA_NOTIFICATION_TAG));
        boolean launchSingleWebsitePreferences = origin != null;

        String fragmentName = launchSingleWebsitePreferences
                ? SingleWebsitePreferences.class.getName()
                : SingleCategoryPreferences.class.getName();
        Intent preferencesIntent =
                PreferencesLauncher.createIntentForSettingsPage(applicationContext, fragmentName);

        Bundle fragmentArguments;
        if (launchSingleWebsitePreferences) {
            // Record that the user has clicked on the [Site Settings] button.
            RecordUserAction.record("Notifications.ShowSiteSettings");

            // All preferences for a specific origin.
            fragmentArguments = SingleWebsitePreferences.createFragmentArgsForSite(origin);
        } else {
            // Notification preferences for all origins.
            fragmentArguments = new Bundle();
            fragmentArguments.putString(SingleCategoryPreferences.EXTRA_CATEGORY,
                    SiteSettingsCategory.CATEGORY_NOTIFICATIONS);
            fragmentArguments.putString(SingleCategoryPreferences.EXTRA_TITLE,
                    applicationContext.getResources().getString(
                            R.string.push_notifications_permission_title));
        }
        preferencesIntent.putExtra(Preferences.EXTRA_SHOW_FRAGMENT_ARGUMENTS, fragmentArguments);

        // We need to ensure that no existing preference tasks are being re-used in order for the
        // new activity to appear on top.
        preferencesIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);

        applicationContext.startActivity(preferencesIntent);
    }

    /**
     * Returns a bogus Uri used to make each intent unique according to Intent#filterEquals.
     * Without this, the pending intents derived from the intent may be reused, because extras are
     * not taken into account for the filterEquals comparison.
     *
     * @param persistentNotificationId The persistent id of the notification.
     * @param origin The origin to whom the notification belongs.
     * @param actionIndex The zero-based index of the action button, or -1 if not applicable.
     */
    private Uri makeIntentData(long persistentNotificationId, String origin, int actionIndex) {
        return Uri.parse(origin).buildUpon().fragment(
                persistentNotificationId + "," + actionIndex).build();
    }

    /**
     * Returns the PendingIntent for completing |action| on the notification identified by the data
     * in the other parameters.
     *
     * @param action The action this pending intent will represent.
     * @param persistentNotificationId The persistent id of the notification.
     * @param origin The origin to whom the notification belongs.
     * @param tag The tag of the notification. May be NULL.
     * @param actionIndex The zero-based index of the action button, or -1 if not applicable.
     */
    private PendingIntent makePendingIntent(String action, long persistentNotificationId,
                                            String origin, @Nullable String tag, int actionIndex) {
        Uri intentData = makeIntentData(persistentNotificationId, origin, actionIndex);
        Intent intent = new Intent(action, intentData);
        intent.setClass(mAppContext, NotificationService.Receiver.class);

        intent.putExtra(NotificationConstants.EXTRA_PERSISTENT_NOTIFICATION_ID,
                persistentNotificationId);
        intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_ORIGIN, origin);
        intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_TAG, tag);
        intent.putExtra(NotificationConstants.EXTRA_NOTIFICATION_INFO_ACTION_INDEX, actionIndex);

        return PendingIntent.getBroadcast(mAppContext, PENDING_INTENT_REQUEST_CODE, intent,
                PendingIntent.FLAG_UPDATE_CURRENT);
    }

    /**
     * Generates the tag to be passed to the notification manager.
     *
     * If the generated tag is the same as that of a previous notification, a new notification shown
     * with this tag will replace it.
     *
     * If the input tag is not empty the output is: PREFIX + SEPARATOR + ORIGIN + SEPARATOR + TAG.
     * This output will be the same for notifications from the same origin that have the same input
     * tag.
     *
     * If the input tag is empty the output is PREFIX + SEPARATOR + ORIGIN + SEPARATOR +
     * NOTIFICATION_ID.
     *
     * @param persistentNotificationId The persistent id of the notification.
     * @param origin The origin for which the notification is shown.
     * @param tag A string identifier for this notification.
     * @return The generated platform tag.
     */
    private static String makePlatformTag(long persistentNotificationId, String origin,
                                          @Nullable String tag) {
        // The given tag may contain the separator character, so add it last to make reading the
        // preceding origin token reliable. If no tag was specified (it is the default empty
        // string), make the platform tag unique by appending the notification id.
        StringBuilder builder = new StringBuilder();
        builder.append(PLATFORM_TAG_PREFIX)
                .append(NotificationConstants.NOTIFICATION_TAG_SEPARATOR)
                .append(origin)
                .append(NotificationConstants.NOTIFICATION_TAG_SEPARATOR);

        if (TextUtils.isEmpty(tag)) {
            builder.append(persistentNotificationId);
        } else {
            builder.append(tag);
        }

        return builder.toString();
    }

    /**
     * Attempts to extract an origin from the tag extra in the given intent.
     *
     * See {@link #makePlatformTag} for details about the format of the tag.
     *
     * @param tag The tag from the intent extra. May be null.
     * @return The origin string. Returns null if there was no tag extra in the given intent, or if
     *         the tag value did not match the expected format.
     */
    @Nullable
    @VisibleForTesting
    static String getOriginFromTag(@Nullable String tag) {
        // If the user touched the settings cog on a flipped notification originating from this
        // class, there will be a notification tag extra in a specific format. From the tag we can
        // read the origin of the notification.
        if (tag == null || !tag.startsWith(PLATFORM_TAG_PREFIX)) return null;

        String[] parts = tag.split(NotificationConstants.NOTIFICATION_TAG_SEPARATOR);
        assert parts.length >= 3;
        try {
            URI uri = new URI(parts[1]);
            if (uri.getHost() != null) return parts[1];
        } catch (URISyntaxException e) {
            Log.e(TAG, "Expected to find a valid url in the notification tag extra.", e);
            return null;
        }
        return null;
    }

    /**
     * Generates the notfiication defaults from vibrationPattern's size and silent.
     *
     * Use the system's default ringtone, vibration and indicator lights unless the notification
     * has been marked as being silent.
     * If a vibration pattern is set, the notification should use the provided pattern
     * rather than the defaulting to system settings.
     *
     * @param vibrationPatternLength Vibration pattern's size for the Notification.
     * @param silent Whether the default sound, vibration and lights should be suppressed.
     * @return The generated notification's default value.
    */
    @VisibleForTesting
    static int makeDefaults(int vibrationPatternLength, boolean silent) {
        assert !silent || vibrationPatternLength == 0;

        if (silent) return 0;

        int defaults = Notification.DEFAULT_ALL;
        if (vibrationPatternLength > 0) {
            defaults &= ~Notification.DEFAULT_VIBRATE;
        }
        return defaults;
    }

    /**
     * Generates the vibration pattern used in Android notification.
     *
     * Android takes a long array where the first entry indicates the number of milliseconds to wait
     * prior to starting the vibration, whereas Chrome follows the syntax of the Web Vibration API.
     *
     * @param vibrationPattern Vibration pattern following the Web Vibration API syntax.
     * @return Vibration pattern following the Android syntax.
    */
    @VisibleForTesting
    static long[] makeVibrationPattern(int[] vibrationPattern) {
        long[] pattern = new long[vibrationPattern.length + 1];
        for (int i = 0; i < vibrationPattern.length; ++i) {
            pattern[i + 1] = vibrationPattern[i];
        }
        return pattern;
    }

    /**
     * Displays a notification with the given details.
     *
     * @param persistentNotificationId The persistent id of the notification.
     * @param origin Full text of the origin, including the protocol, owning this notification.
     * @param tag A string identifier for this notification. If the tag is not empty, the new
     *            notification will replace the previous notification with the same tag and origin,
     *            if present. If no matching previous notification is present, the new one will just
     *            be added.
     * @param title Title to be displayed in the notification.
     * @param body Message to be displayed in the notification. Will be trimmed to one line of
     *             text by the Android notification system.
     * @param icon Icon to be displayed in the notification. Valid Bitmap icons will be scaled to
     *             the platforms, whereas a default icon will be generated for invalid Bitmaps.
     * @param vibrationPattern Vibration pattern following the Web Vibration syntax.
     * @param silent Whether the default sound, vibration and lights should be suppressed.
     * @param actionTitles Titles of actions to display alongside the notification.
     * @see https://developer.android.com/reference/android/app/Notification.html
     */
    @CalledByNative
    private void displayNotification(long persistentNotificationId, String origin, String tag,
            String title, String body, Bitmap icon, int[] vibrationPattern, boolean silent,
            String[] actionTitles) {
        Resources res = mAppContext.getResources();

        // Set up a pending intent for going to the settings screen for |origin|.
        Intent settingsIntent = PreferencesLauncher.createIntentForSettingsPage(
                mAppContext, SingleWebsitePreferences.class.getName());
        settingsIntent.setData(makeIntentData(persistentNotificationId, origin,
                                              -1 /* actionIndex */));
        settingsIntent.putExtra(Preferences.EXTRA_SHOW_FRAGMENT_ARGUMENTS,
                SingleWebsitePreferences.createFragmentArgsForSite(origin));

        PendingIntent pendingSettingsIntent = PendingIntent.getActivity(mAppContext,
                PENDING_INTENT_REQUEST_CODE, settingsIntent, PendingIntent.FLAG_UPDATE_CURRENT);

        NotificationBuilder notificationBuilder = createNotificationBuilder()
                .setTitle(title)
                .setBody(body)
                .setLargeIcon(ensureNormalizedIcon(icon, origin))
                .setSmallIcon(R.drawable.ic_chrome)
                .setContentIntent(makePendingIntent(
                        NotificationConstants.ACTION_CLICK_NOTIFICATION,
                        persistentNotificationId, origin, tag, -1 /* actionIndex */))
                .setDeleteIntent(makePendingIntent(
                        NotificationConstants.ACTION_CLOSE_NOTIFICATION,
                        persistentNotificationId, origin, tag, -1 /* actionIndex */))
                .setTicker(createTickerText(title, body))
                .setOrigin(UrlUtilities.formatUrlForSecurityDisplay(
                        origin, false /* showScheme */));

        for (int actionIndex = 0; actionIndex < actionTitles.length; actionIndex++) {
            notificationBuilder.addAction(
                    0 /* actionIcon */, actionTitles[actionIndex],
                    makePendingIntent(NotificationConstants.ACTION_CLICK_NOTIFICATION,
                                      persistentNotificationId, origin, tag, actionIndex));
        }
        // Site settings button is always the last action button.
        if (actionTitles.length == 0) {
            notificationBuilder.addAction(R.drawable.settings_cog,
                                          res.getString(R.string.page_info_site_settings_button),
                                          pendingSettingsIntent);
        } else {
            // Hide site settings icon and use shorter text when website provided action buttons.
            notificationBuilder.addAction(0 /* actionIcon */,
                                          res.getString(R.string.notification_site_settings_button),
                                          pendingSettingsIntent);
        }

        notificationBuilder.setDefaults(makeDefaults(vibrationPattern.length, silent));
        if (vibrationPattern.length > 0) {
            notificationBuilder.setVibrate(makeVibrationPattern(vibrationPattern));
        }

        String platformTag = makePlatformTag(persistentNotificationId, origin, tag);
        mNotificationManager.notify(platformTag, PLATFORM_ID, notificationBuilder.build());
    }

    private NotificationBuilder createNotificationBuilder() {
        if (useCustomLayouts()) {
            return new CustomNotificationBuilder(mAppContext);
        }
        return new StandardNotificationBuilder(mAppContext);
    }

    /**
     * Creates the ticker text for a notification having |title| and |body|. The notification's
     * title will be printed in bold, followed by the text of the body.
     *
     * @param title Title of the notification.
     * @param body Textual contents of the notification.
     * @return A character sequence containing the ticker's text.
     */
    private CharSequence createTickerText(String title, String body) {
        SpannableStringBuilder spannableStringBuilder = new SpannableStringBuilder();

        spannableStringBuilder.append(title);
        spannableStringBuilder.append("\n");
        spannableStringBuilder.append(body);

        // Mark the title of the notification as being bold.
        spannableStringBuilder.setSpan(new StyleSpan(android.graphics.Typeface.BOLD),
                0, title.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);

        return spannableStringBuilder;
    }

    /**
     * Ensures the availability of an icon for the notification.
     *
     * If |icon| is a valid, non-empty Bitmap, the bitmap will be scaled to be of an appropriate
     * size for the current Android device. Otherwise, a default icon will be created based on the
     * origin the notification is being displayed for.
     *
     * @param icon The developer-provided icon they intend to use for the notification.
     * @param origin The origin the notification is being displayed for.
     * @return An appropriately sized icon to use for the notification.
     */
    @VisibleForTesting
    public Bitmap ensureNormalizedIcon(Bitmap icon, String origin) {
        if (icon == null || icon.getWidth() == 0) {
            if (mIconGenerator == null) {
                int cornerRadiusPx = Math.min(mLargeIconWidthPx, mLargeIconHeightPx) / 2;
                mIconGenerator =
                        new RoundedIconGenerator(mLargeIconWidthPx, mLargeIconHeightPx,
                                                 cornerRadiusPx,
                                                 NOTIFICATION_ICON_BG_COLOR,
                                                 NOTIFICATION_TEXT_SIZE_DP * mDensity);
            }

            return mIconGenerator.generateIconForUrl(origin, true);
        }

        if (icon.getWidth() > mLargeIconWidthPx || icon.getHeight() > mLargeIconHeightPx) {
            return icon.createScaledBitmap(icon, mLargeIconWidthPx, mLargeIconHeightPx,
                                           false /* not filtered */);
        }

        return icon;
    }

    private static boolean useCustomLayouts() {
        // TODO(mvanouwerkerk): Add Finch experiment for enabling / disabling the feature.
        CommandLine commandLine = CommandLine.getInstance();
        if (commandLine.hasSwitch(ChromeSwitches.DISABLE_WEB_NOTIFICATION_CUSTOM_LAYOUTS)) {
            return false;
        }
        return commandLine.hasSwitch(ChromeSwitches.ENABLE_WEB_NOTIFICATION_CUSTOM_LAYOUTS);
    }

    /**
     * Returns whether a notification has been clicked in the last 5 seconds.
     * Used for Startup.BringToForegroundReason UMA histogram.
     */
    public static boolean wasNotificationRecentlyClicked() {
        if (sInstance == null) return false;
        long now = System.currentTimeMillis();
        return now - sInstance.mLastNotificationClickMs < 5 * 1000;
    }

    /**
     * Closes the notification associated with the given parameters.
     *
     * @param persistentNotificationId The persistent id of the notification.
     * @param origin The origin to which the notification belongs.
     * @param tag The tag of the notification. May be NULL.
     */
    @CalledByNative
    private void closeNotification(long persistentNotificationId, String origin, String tag) {
        String platformTag = makePlatformTag(persistentNotificationId, origin, tag);
        mNotificationManager.cancel(platformTag, PLATFORM_ID);
    }

    /**
     * Calls NotificationUIManagerAndroid::OnNotificationClicked in native code to indicate that
     * the notification with the given parameters has been clicked on.
     *
     * @param persistentNotificationId The persistent id of the notification.
     * @param origin The origin of the notification.
     * @param tag The tag of the notification. May be NULL.
     * @return Whether the manager could handle the click event.
     */
    private boolean onNotificationClicked(long persistentNotificationId, String origin,
                                          String tag, int actionIndex) {
        mLastNotificationClickMs = System.currentTimeMillis();
        return nativeOnNotificationClicked(mNativeNotificationManager, persistentNotificationId,
                                           origin, tag, actionIndex);
    }

    /**
     * Calls NotificationUIManagerAndroid::OnNotificationClosed in native code to indicate that
     * the notification with the given parameters has been closed.
     *
     * @param persistentNotificationId The persistent id of the notification.
     * @param origin The origin of the notification.
     * @param tag The tag of the notification. May be NULL.
     * @param byUser Whether the notification was closed by a user gesture.
     * @return Whether the manager could handle the close event.
     */
    private boolean onNotificationClosed(long persistentNotificationId, String origin,
                                         String tag, boolean byUser) {
        return nativeOnNotificationClosed(mNativeNotificationManager, persistentNotificationId,
                                          origin, tag, byUser);
    }

    private static native void nativeInitializeNotificationUIManager();

    private native boolean nativeOnNotificationClicked(long nativeNotificationUIManagerAndroid,
            long persistentNotificationId, String origin, String tag, int actionIndex);
    private native boolean nativeOnNotificationClosed(long nativeNotificationUIManagerAndroid,
            long persistentNotificationId, String origin, String tag, boolean byUser);
}
